iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 23
1

昨天已經將video stream添加到video元素中,今天要來繼續實作當用戶按下「拍照鈕」後,會將最新的「video stream snapshot」傳送到canvas元素,接著就可以將放在canvas元素中的圖片存到後台資料庫。

看一下要怎麼實現上面的邏輯吧:/images/emoticon/emoticon34.gif

首先要再feed.js中新增一個「當用戶點擊拍照鍵時的event listener

captureButton.addEventListener('click', function(event) {
  canvasElement.style.display = 'block';
  videoPlayer.style.display = 'none';
  captureButton.style.display = 'none';
  var context = canvasElement.getContext('2d');
  context.drawImage(videoPlayer, 0, 0, canvas.width, videoPlayer.videoHeight / (videoPlayer.videoWidth / canvas.width));
  videoPlayer.srcObject.getVideoTracks().forEach(function(track) {
    track.stop();
  })
});

在這個監聽器中,針對UI的部分,先將canvas元素顯示出來和video元素隱藏起來,最後也順便將拍照鍵隱藏起來。接著針對canvas這個element,顧名思義這是一個畫布網頁元素,可以在上面放置任何圖像。

所以我在這裡先透過「canvasElement.getContext()這個方法」來定義要從這個畫布上獲取的內容類型(也就是一個"2d"的影像)。

然後使用drawImage()方法將影像放置於畫布上,這個方法一共可以輸入5個參數,前3個代表「從座標點(0, 0)開始畫上videoPlayer指定的影像來源」,最後的兩個參數是讓我們在畫布上放置影像的同時可以進行「縮放影像」(這裡我將縮放寬度訂為canvas.width,而高度則是根據video元素的大小成比例縮放)。

最後,記得要將video元素(videoPlayer)給關閉,否則裝置的攝像鏡頭就會一直開著。關閉的方式有很多中,這裡是使用videoPlayer.srcObject.getVideoTracks()取得所有的video tracks(雖然這裡只有一個track),並使用forEach()來處理回傳的track陣列,針對每個track呼叫stop() method。


目前影像是存放在canvas元素中,那要如何將它儲存成一個檔案並傳送到後台的firebase storage

首先,要知道儲存在canvas中的影像預設類型是一個base64 url string要將「base64字串」轉換成「一般檔案類型」,這裡需要引用別人寫好的code(並把它放在utility.js中):

function dataURItoBlob(dataURI) {
    var byteString = atob(dataURI.split(',')[1]);
    var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
    var ab = new ArrayBuffer(byteString.length);
    var ia = new Uint8Array(ab);
    for (var i = 0; i < byteString.length; i++) {
      ia[i] = byteString.charCodeAt(i);
    }
    var blob = new Blob([ab], {type: mimeString});
    return blob;
}

接下來在剛剛寫好的「拍照鈕event listener」中,加入下列第二行的程式碼。也就是使用canvasElement.toDataURL()回傳含有圖像和參數設置特定格式的base64 URI string(預設為PNG格式),最後使用別人寫好的function將圖像轉換為一般檔案類型。

var picture; // 這行要放在global scope中
picture = dataURItoBlob(canvasElement.toDataURL());

還記得我們有在兩個地方(feed.js和sw.js中)有呼叫Firebase Cloud Funcitons,將用戶發佈的貼文資訊傳送到資料庫中嗎?當初傳送時的image欄位都是寫死的,而且傳送的檔案類型都是json format。

不過現在為了要傳送檔案,所以不能再使用json格式了,必須要使用「表單資料(Form Data)」來傳送用戶的發文資料。

先來看一下要怎麼在sw.js中修改,首先在監聽sync事件傳送post data時,要將原本body中的json format改成FormData的格式。

self.addEventListener('sync', function(event) {
    console.log('[Service Worker] Background syncing', event);
    if(event.tag === 'sync-new-posts') {
        console.log('[Service Worker] Syncing new Posts');
        event.waitUntil(
            readAllData('sync-posts').then(function(data) {
                for(var dt of data) {
                    // 新增表單資料
                    var postData = new FormData();
                    // 透過append()來新增key-value資料
                    postData.append('id', dt.id);
                    postData.append('title', dt.title);
                    postData.append('location', dt.location);
                    postData.append('file', dt.picture, dt.id + '.png');
                    
                    fetch('https://us-central1-trip-diary-f56de.cloudfunctions.net/storePostData', {
                        method: 'POST',
                        body: postData
                    }).then(function(res) {
                        console.log('Send data', res);
                        if(res.ok) {
                            res.json().then(function(resData) {
                                deleteItemFromData('sync-posts', resData.id);
                            });
                        }
                    }).catch(function(err) {
                        console.log('Error while sending data', err); 
                    });
                }
            })
        );
    }
});

接著修改在feed.js中的sendData() function,跟剛剛修改的方式相同,將原本body中的json format改成FormData的格式:

function sendData() {
  var id = new Date().toISOString();
  var postData = new FormData();
  postData.append('id', id);
  postData.append('title', titleInput.value);
  postData.append('location', locationInput.value);
  postData.append('file', picture, id + '.png');

  fetch('https://us-central1-trip-diary-f56de.cloudfunctions.net/storePostData', {
    method: 'POST',
    body: postData
  }).then(function(res) {
    console.log('Send data', res);
    // updateUI();
  })
}

之前說明過在feed.js中監聽submit event時,會先將「要背景同步的用戶貼文資料」暫時寫入indexedDB,當初並沒有將picture存進去,記得要加上去,否則剛剛在sw.js新增的FormData中,dt.picture就會是null:

... 上方省略 ...
if('serviceWorker' in navigator && 'SyncManager' in window) {
    navigator.serviceWorker.ready.then(function(sw) {
      var post = {
        id: new Date().toISOString(),
        title: titleInput.value,
        location: locationInput.value,
        picture: picture
      };
      writeData('sync-posts', post).then(function() {
        return sw.sync.register('sync-new-posts');
      })
... 下方省略 ...

最後要開始在Firebase Cloud Funcitons中針對剛剛傳入的FormData進行處理 /images/emoticon/emoticon10.gif

首先,要先安裝一些等一下會使用到的套件:

npm install --save busboy @google-cloud/storage@^1.2.1 uuid-v4
  • busboy:busboy模組是用来解析POST Request。
  • @google-cloud/storage:firebase提供能存取google cloud storage的客戶端。
  • uuid-v4:產生Version 4 的UUID(Universally Unique Identifier)亂數。

接下來在functions/index.js中,導入上述的這些模組吧:

var fs = require('fs');   // node原生模組,是用來操作(read/write)實體檔案
var UUID = require('uuid-v4');
var Busboy = require('busboy');
var os = require('os');
var path = require('path');

在導入google cloud storage模組前要先初始化一些設定,包話「專案ID」和「專案的私鑰資訊

var gcconfig = {
    projectId: 'trip-diary-f56de',
    keyFilename: 'trip-diary-firebase-key.json'
  };
  
var gcs = require('@google-cloud/storage')(gcconfig);

終於可以開始處理傳入的FormData,這裡我直接在程式碼註解中一行行說明:

exports.storePostData = functions.https.onRequest((request, response) => {
    cors(request, response, function() {
        // 產生唯一識別的亂數
        var uuid = UUID();
        
        // request為node的原生請求
        const busboy = new Busboy({ headers: request.headers });
        
        // 之後會定義要上傳的檔案路徑和類型
        let upload;
        
        // 建立fields object,之後將解析完成的request field加入
        const fields = {};
        
        // 開始監聽file解析事件(也就是用戶拍照的圖片)
        busboy.on("file", (fieldname, file, filename, encoding, mimetype) => {
            // 定義file要保存的路徑
            const filepath = path.join(os.tmpdir(), filename);
            
            // 定義要上傳的檔案路徑和類型
            upload = { file: filepath, type: mimetype };
            
            // 將圖片(file)保存到特定路徑
            file.pipe(fs.createWriteStream(filepath));
        });
        
        // 開始監聽request中的表單欄位(field)
        busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) {
            fields[fieldname] = val;
        });
        
        // 監聽結束事件
        // 也就是當解析完POST request後,要開始上傳到google cloud storage的bucket了
        busboy.on("finish", () => {
            var bucket = gcs.bucket("trip-diary-f56de.appspot.com");
            bucket.upload(
                upload.file,   // 要上傳的檔案路徑
                {
                    uploadType: "media",
                    metadata: {
                        metadata: {
                            contentType: upload.type,   // 要上傳的檔案類型
                            firebaseStorageDownloadTokens: uuid   // 圖片下載連結的唯一識別碼
                        }
                    }
                },
                // 上傳完成後,執行該callback function
                function(err, uploadedFile) {
                    if(!err) {
                        // 若無錯誤,開始執行原本push notification的程式碼
                        // 將push到資料庫的欄位,將「request.body」改成解析完成的「fields」
                        admin.database().ref('posts').push({
                            id: fields.id,
                            title: fields.title,
                            location: fields.location,
                            image: "https://firebasestorage.googleapis.com/v0/b/" + bucket.name + "/o/" + encodeURIComponent(uploadedFile.name) + "?alt=media&token=" + uuid
                        }).then(function() {
                            webpush.setVapidDetails('mailto:j84077200345@gmail.com', 'BDIOql6aKK-00AGzVKggeN9LSpjGd2golLzuiCvmUG0NAIa3wi-FmG17HElLHhXtzQBQQ9faZmJ2MWW87VI8bgg', 'JxB633wEwprQT3hahwrNPoimHshPRj0Kd9OK11IXlQ8');
                            return admin.database().ref('subscriptions').once('value');
                        }).then(function(subscriptions) {
                            subscriptions.forEach(function(sub) {
                                var pushConfig = {
                                    endpoint: sub.val().endpoint,
                                    keys: {
                                        auth: sub.val().keys.auth,
                                        p256dh: sub.val().keys.p256dh
                                    }
                                };
                
                                webpush.sendNotification(pushConfig, JSON.stringify({title: '新貼文', content: '有新增的貼文!!', openUrl: '/'})).catch(function(err) {
                                    console.log(err);
                                });
                            });
                            response.status(201).json({message: 'Data Stored', id: fields.id});
                        }).catch(function(err) {
                            response.status(500).json({error: err});
                        })
                    } else {
                        console.log(err);
                    }
                }
            );
        });

        busboy.end(request.rawBody);
    });
});

最後如果用戶沒有攝像鏡頭的話,我們有一個image picker來讓用戶選擇想要的圖片上傳。這個實作其實非常簡單,來看一下在feed.js中要怎麼寫:

imagePicker.addEventListener('change', function(event) {
  picture = event.target.files[0];
});

監聽imagePicker改變(change)的事件,當用戶選好檔案,可以使用event.target.files[0]來取得選擇的檔案array(這裡我預設只有選擇一張圖片)。

Day26 結束!! /images/emoticon/emoticon37.gif


上一篇
[Day25] 了解Media API和Geolocation API(Part1)
下一篇
[Day27] 了解Media API和Geolocation API(Part3)
系列文
你應該要知道的新一代Web技術---漸進式網頁(PWA)29
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言